跳到主要内容

Go 的 benchmark 基准测试

什么是基准测试?

通过设计合理的测试方法,选用合适的测试工具和被测系统,实现对某个特定目标场景的某项性能指标进行定量的和可对比的测试。

基准测试的意义

  1. 为容量规划确定系统和应用程序的极限;
  2. 为配置测试的参数和配置选项提供参考依据;
  3. 为验收测试确定系统是否具备自己所宣称的能力;
  4. 为性能基线的建立提供长期的数据统计来源以及比较基准;

稳定的测试环境

当我们尝试去优化代码的性能时,首先得知道当前的性能怎么样。Go 语言标准库内置的 testing 测试框架提供了基准测试(benchmark)的能力,能让我们很容易地对某一段代码进行性能测试。

性能测试受环境的影响很大,为了保证测试的可重复性,在进行性能测试时,尽可能地保持测试环境的稳定。

常用的工具函数

Run 子测试

Go1.7+中新增了子测试,支持在测试函数中使用 t.Run 执行一组测试用例,这样就不需要为不同的测试数据定义多个测试函数了。

子测试和其他普通的测试函数一样,是在独立的 goroutine 中运行,测试结果也会计入测试报告,所有子测试运行完毕后,父测试函数才会结束。

func TestXXX(t *testing.B){
t.Run("case1", func(t *testing.B){...})
t.Run("case2", func(t *testing.B){...})
t.Run("case3", func(t *testing.B){...})
}

RunParallel 并行测试

以并行的方式执行给定的基准测试。

RunParallel 会创建出多个 goroutine , 并将 b.N 个迭代分配给这些 goroutine 执行, 其中 goroutine 数量的默认值为 GOMAXPROCS 。 用户如果想要增加非CPU受限(non-CPU-bound)基准测试的并行性, 那么可以在 RunParallel 之前调用 SetParallelism 。 RunParallel 通常会与 -cpu 标志一同使用。

func BenchmarkTemplateParallel(b *testing.B) {
templ := template.Must(template.New("test").Parse("Hello, {{.}}!"))
b.RunParallel(func(pb *testing.PB) {
// 每个 goroutine 有属于自己的 bytes.Buffer.
var buf bytes.Buffer
for pb.Next() {
// 循环体在所有 goroutine 中总共执行 b.N 次
buf.Reset()
templ.Execute(&buf, "World")
}
})
}

body 函数将在每个 goroutine 中执行, 这个函数需要设置所有 goroutine 本地的状态, 并迭代直到 pb.Next 返回 false 值为止。

benchmark 的使用

Go 语言标准库内置了支持 benchmark 的 testing 库,接下来看一个简单的例子:

使用 go mod init example 初始化一个模块,新增 fib.go 文件,实现函数 fib,用于计算第 N 个菲波那切数。

// fib.go
package main

func fib(n int) int {
if n == 0 || n == 1 {
return n
}
return fib(n-2) + fib(n-1)
}

接下来,我们在 fib_test.go 中实现一个 benchmark 用例:

package main

import "testing"

func BenchmarkFib(b *testing.B) {
for n := 0; n < b.N; n++ {
fib(30) // run fib(30) b.N times
}
}

benchmark 和普通的单元测试用例一样,都位于 _test.go 文件中。

函数名以 Benchmark 开头,参数是 b *testing.B。和普通的单元测试用例很像,单元测试函数名以 Test 开头,参数是 t *testing.T

运行用例

go test <module name>/<package name> 

用来运行某个 package 内的所有测试用例。

运行当前 package 内的用例:go test examplego test . 运行子 package 内的用例:go test example/<package name>go test ./<package name> 如果想递归测试当前目录下的所有的 package:go test ./...go test example/...

go test 命令默认不运行 benchmark 用例的,如果我们想运行 benchmark 用例,则需要加上 -bench 参数。例如:

go test -bench .

-bench 参数支持传入一个正则表达式,匹配到的用例才会得到执行,例如,只运行以 Fib 结尾的 benchmark 用例:

go test -bench='Fib$' .

benchmark 是如何工作的

benchmark 用例的参数 b *testing.B,有个属性 b.N 表示这个用例需要运行的次数。b.N 对于每个用例都是不一样的。

那这个值是如何决定的呢?

b.N 从 1 开始,如果该用例能够在 1s 内完成,b.N 的值便会增加,再次执行。b.N 的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快。我们仔细观察上述例子的输出:

BenchmarkFib-8               230           5218875 ns/op

BenchmarkFib-8 中的 -8 即 GOMAXPROCS,默认等于 CPU 核数。可以通过 -cpu 参数改变 GOMAXPROCS,-cpu 支持传入一个列表作为参数,例如:

# 分别使用 2 核 和 4 核 进行基准测试
go test -bench='Fib$' -cpu="2,4" .

在这个例子中,改变 CPU 的核数对结果几乎没有影响,因为这个 Fib 的调用是串行的。

234 和 5073030 ns/op 表示用例执行了 234 次,每次花费约 0.006s。总耗时比 1s 略多。

提升准确度 ⭐

对于性能测试来说,提升测试准确度的一个重要手段就是增加测试的次数。我们可以使用 -benchtime-count 两个参数达到这个目的。

benchmark 的默认时间是 1s,那么我们可以使用 -benchtime 指定为 5s。例如:

go test -bench='Fib$' -benchtime=5s .

实际执行的时间是 6.432s,比 benchtime 的 5s 要长,测试用例编译、执行、销毁等是需要时间的。

-benchtime 设置为 5s,用例执行次数也变成了原来的 5倍,每次函数调用时间仍为 0.6s,几乎没有变化。

这个 -benchtime 的值除了是时间外,还可以是具体的次数。例如,执行 30 次可以用 -benchtime=30x

go test -bench='Fib$' -benchtime=50x .

调用 50 次 fib(30),仅花费了 0.321s。

-count 参数可以用来设置 benchmark 的轮数。例如,进行 3 轮 benchmark。

go test -bench='Fib$' -benchtime=5s -count=3 .

内存分配情况

-benchmem 参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销。

在下面的例子中,generateWithCap 和 generate 的作用是一致的,生成一组长度为 n 的随机序列。唯一的不同在于,generateWithCap 创建切片时,将切片的容量(capacity)设置为 n,这样切片就会一次性申请 n 个整数所需的内存。

创建 generate_test.go 文件

// generate_test.go
package main

import (
"math/rand"
"testing"
"time"
)

func generateWithCap(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0, n)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}

func generate(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}

func BenchmarkGenerateWithCap(b *testing.B) {
for n := 0; n < b.N; n++ {
generateWithCap(1000000)
}
}

func BenchmarkGenerate(b *testing.B) {
for n := 0; n < b.N; n++ {
generate(1000000)
}
}

运行该用例的结果是:

go test -bench='Generate' .

可以看到生成 100w 个数字的随机序列,GenerateWithCap 的耗时比 Generate 少 20%。

我们可以使用 -benchmem 参数看到内存分配的情况:

Generate 分配的内存是 GenerateWithCap 的 6 倍,设置了切片容量,内存只分配一次,而不设置切片容量,内存分配了 40 次。

测试不同的输入

不同的函数复杂度不同, O(1),O(n),O(n2)O(1), O(n), O(n^2) 等,利用 benchmark 验证复杂度一个简单的方式,是构造不同的输入。对刚才的 benchmark 稍作改造,便能够达到目的。

import (
"math/rand"
"testing"
"time"
)

func generate(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}

func benchmarkGenerate(i int, b *testing.B) {
for n := 0; n < b.N; n++ {
generate(i)
}
}

func BenchmarkGenerate1000(b *testing.B) { benchmarkGenerate(1000, b) }
func BenchmarkGenerate10000(b *testing.B) { benchmarkGenerate(10000, b) }
func BenchmarkGenerate100000(b *testing.B) { benchmarkGenerate(100000, b) }
func BenchmarkGenerate1000000(b *testing.B) { benchmarkGenerate(1000000, b) }

这里,我们实现一个辅助函数 benchmarkGenerate 允许传入参数 i,并构造了 4 个不同输入的 benchmark 用例。运行结果如下:

go test -bench='Generate' -benchmem .

通过测试结果可以发现,输入变为原来的 10 倍,函数每次调用的时长也差不多是原来的 10 倍,这说明复杂度是线性的。

benchmark 注意事项

ResetTimer 重置计时器

如果在 benchmark 开始前,需要一些准备工作,如果准备工作比较耗时,则需要将这部分代码的耗时忽略掉。比如下面的例子:

func BenchmarkFib(b *testing.B) {
time.Sleep(time.Second * 3) // 模拟耗时准备任务
for n := 0; n < b.N; n++ {
fib(30) // run fib(30) b.N times
}
}

这种时候就需要使用 ResetTimer 屏蔽掉:

func BenchmarkFib(b *testing.B) {
time.Sleep(time.Second * 3) // 模拟耗时准备任务
b.ResetTimer() // 重置定时器
for n := 0; n < b.N; n++ {
fib(30) // run fib(30) b.N times
}
}

StopTimer & StartTimer

还有一种情况,每次函数调用前后需要一些准备工作和清理工作,我们可以使用 StopTimer 暂停计时以及使用 StartTimer 开始计时。

例如,如果测试一个冒泡函数的性能,每次调用冒泡函数前,需要随机生成一个数字序列,这是非常耗时的操作,这种场景下,就需要使用 StopTimer 和 StartTimer 避免将这部分时间计算在内。

例如:

// sort_test.go
package main

import (
"math/rand"
"testing"
"time"
)

func generateWithCap(n int) []int {
rand.Seed(time.Now().UnixNano())
nums := make([]int, 0, n)
for i := 0; i < n; i++ {
nums = append(nums, rand.Int())
}
return nums
}

func bubbleSort(nums []int) {
for i := 0; i < len(nums); i++ {
for j := 1; j < len(nums)-i; j++ {
if nums[j] < nums[j-1] {
nums[j], nums[j-1] = nums[j-1], nums[j]
}
}
}
}

func BenchmarkBubbleSort(b *testing.B) {
for n := 0; n < b.N; n++ {
b.StopTimer()
nums := generateWithCap(10000)
b.StartTimer()
bubbleSort(nums)
}
}

References